<!doctype html>
<meta charset=utf-8>
<title>Animation interface: style change events</title>
<link rel="help"
      href="https://drafts.csswg.org/web-animations-1/#model-liveness">
<script src="/resources/testharness.js"></script>
<script src="/resources/testharnessreport.js"></script>
<script src="../../testcommon.js"></script>
<body>
<div id="log"></div>
<script>
'use strict';

// Test that each property defined in the Animation interface behaves as
// expected with regards to whether or not it produces style change events.
//
// There are two types of tests:
//
//   PlayAnimationTest
//
//     For properties that are able to cause the Animation to start affecting
//     the target CSS property.
//
//     This function takes either:
//
//     (a) A function that simply "plays" that passed-in Animation (i.e. makes
//         it start affecting the target CSS property.
//
//     (b) An object with the following format:
//
//         {
//            setup: elem => { /* return Animation */ },
//            test: animation => { /* play |animation| */ },
//            shouldFlush: boolean /* optional, defaults to false */
//         }
//
//     If the latter form is used, the setup function should return an Animation
//     that does NOT (yet) have an in-effect AnimationEffect that affects the
//     'opacity' property. Otherwise, the transition we use to detect if a style
//     change event has occurred will never have a chance to be triggered (since
//     the animated style will clobber both before-change and after-change
//     style).
//
//     Examples of valid animations:
//
//       - An animation that is idle, or finished but without a fill mode.
//       - An animation with an effect that that does not affect opacity.
//
//  UsePropertyTest
//
//    For properties that cannot cause the Animation to start affecting the
//    target CSS property.
//
//    The shape of the parameter to the UsePropertyTest is identical to the
//    PlayAnimationTest. The only difference is that the function (or 'test'
//    function of the object format is used) does not need to play the
//    animation, but simply needs to get/set the property under test.

const PlayAnimationTest = testFuncOrObj => {
  let test, setup, shouldFlush;

  if (typeof testFuncOrObj === 'function') {
    test = testFuncOrObj;
    shouldFlush = false;
  } else {
    test = testFuncOrObj.test;
    if (typeof testFuncOrObj.setup === 'function') {
      setup = testFuncOrObj.setup;
    }
    shouldFlush = !!testFuncOrObj.shouldFlush;
  }

  if (!setup) {
    setup = elem =>
      new Animation(
        new KeyframeEffect(elem, { opacity: [0, 1] }, 100 * MS_PER_SEC)
      );
  }

  return { test, setup, shouldFlush };
};

const UsePropertyTest = testFuncOrObj => {
  const { setup, test, shouldFlush } = PlayAnimationTest(testFuncOrObj);

  let coveringAnimation;
  return {
    setup: elem => {
      coveringAnimation = new Animation(
        new KeyframeEffect(elem, { opacity: [0, 1] }, 100 * MS_PER_SEC)
      );

      return setup(elem);
    },
    test: animation => {
      test(animation);
      coveringAnimation.play();
    },
    shouldFlush,
  };
};

const tests = {
  id: UsePropertyTest(animation => (animation.id = 'yer')),
  get effect() {
    let effect;
    return PlayAnimationTest({
      setup: elem => {
        // Create a new effect and animation but don't associate them yet
        effect = new KeyframeEffect(
          elem,
          { opacity: [0.5, 1] },
          100 * MS_PER_SEC
        );
        return elem.animate(null, 100 * MS_PER_SEC);
      },
      test: animation => {
        // Read the effect
        animation.effect;

        // Assign the effect
        animation.effect = effect;
      },
    });
  },
  timeline: PlayAnimationTest({
    setup: elem => {
      // Create a new animation with no timeline
      const animation = new Animation(
        new KeyframeEffect(elem, { opacity: [0.5, 1] }, 100 * MS_PER_SEC),
        null
      );
      // Set the hold time so that once we assign a timeline it will begin to
      // play.
      animation.currentTime = 0;

      return animation;
    },
    test: animation => {
      // Get the timeline
      animation.timeline;

      // Play the animation by setting the timeline
      animation.timeline = document.timeline;
    },
  }),
  startTime: PlayAnimationTest(animation => {
    // Get the startTime
    animation.startTime;

    // Play the animation by setting the startTime
    animation.startTime = document.timeline.currentTime;
  }),
  currentTime: PlayAnimationTest(animation => {
    // Get the currentTime
    animation.currentTime;

    // Play the animation by setting the currentTime
    animation.currentTime = 0;
  }),
  playbackRate: UsePropertyTest(animation => {
    // Get and set the playbackRate
    animation.playbackRate = animation.playbackRate * 1.1;
  }),
  playState: UsePropertyTest(animation => animation.playState),
  pending: UsePropertyTest(animation => animation.pending),
  // Strictly speaking, rangeStart and rangeEnd can change whether the effect
  // is active, but only if the animation has a view timeline. Otherwise, it has
  // no effect.
  rangeStart: UsePropertyTest(animation => animation.rangeStart),
  rangeEnd:  UsePropertyTest(animation => animation.rangeEnd),
  progress: UsePropertyTest(animation => animation.progress),
  replaceState: UsePropertyTest(animation => animation.replaceState),
  ready: UsePropertyTest(animation => animation.ready),
  finished: UsePropertyTest(animation => {
    // Get the finished Promise
    animation.finished;
  }),
  onfinish: UsePropertyTest(animation => {
    // Get the onfinish member
    animation.onfinish;

    // Set the onfinish menber
    animation.onfinish = () => {};
  }),
  onremove: UsePropertyTest(animation => {
    // Get the onremove member
    animation.onremove;

    // Set the onremove menber
    animation.onremove = () => {};
  }),
  oncancel: UsePropertyTest(animation => {
    // Get the oncancel member
    animation.oncancel;

    // Set the oncancel menber
    animation.oncancel = () => {};
  }),
  cancel: UsePropertyTest({
    // Animate _something_ just to make the test more interesting
    setup: elem => elem.animate({ color: ['green', 'blue'] }, 100 * MS_PER_SEC),
    test: animation => {
      animation.cancel();
    },
  }),
  finish: PlayAnimationTest({
    setup: elem =>
      new Animation(
        new KeyframeEffect(
          elem,
          { opacity: [0.5, 1] },
          {
            duration: 100 * MS_PER_SEC,
            fill: 'both',
          }
        )
      ),
    test: animation => {
      animation.finish();
    },
  }),
  play: PlayAnimationTest(animation => animation.play()),
  pause: PlayAnimationTest(animation => {
    // Pause animation -- this will cause the animation to transition from the
    // 'idle' state to the 'paused' (but pending) state with hold time zero.
    animation.pause();
  }),
  updatePlaybackRate: UsePropertyTest(animation => {
    animation.updatePlaybackRate(1.1);
  }),
  // We would like to use a PlayAnimationTest here but reverse() is async and
  // doesn't start applying its result until the animation is ready.
  reverse: UsePropertyTest({
    setup: elem => {
      // Create a new animation and seek it to the end so that it no longer
      // affects style (since it has no fill mode).
      const animation = elem.animate({ opacity: [0.5, 1] }, 100 * MS_PER_SEC);
      animation.finish();
      return animation;
    },
    test: animation => {
      animation.reverse();
    },
  }),
  persist: PlayAnimationTest({
    setup: async elem => {
      // Create an animation whose replaceState is 'removed'.
      const animA = elem.animate(
        { opacity: 1 },
        { duration: 1, fill: 'forwards' }
      );
      const animB = elem.animate(
        { opacity: 1 },
        { duration: 1, fill: 'forwards' }
      );
      await animA.finished;
      animB.cancel();

      return animA;
    },
    test: animation => {
      animation.persist();
    },
  }),
  commitStyles: PlayAnimationTest({
    setup: async elem => {
      // Create an animation whose replaceState is 'removed'.
      const animA = elem.animate(
        // It's important to use opacity of '1' here otherwise we'll create a
        // transition due to updating the specified style whereas the transition
        // we want to detect is the one from flushing due to calling
        // commitStyles.
        { opacity: 1 },
        { duration: 1, fill: 'forwards' }
      );
      const animB = elem.animate(
        { opacity: 1 },
        { duration: 1, fill: 'forwards' }
      );
      await animA.finished;
      animB.cancel();

      return animA;
    },
    test: animation => {
      animation.commitStyles();
    },
    shouldFlush: true,
  }),
  get ['Animation constructor']() {
    let originalElem;
    return UsePropertyTest({
      setup: elem => {
        originalElem = elem;
        // Return a dummy animation so the caller has something to wait on
        return elem.animate(null);
      },
      test: () =>
        new Animation(
          new KeyframeEffect(
            originalElem,
            { opacity: [0.5, 1] },
            100 * MS_PER_SEC
          )
        ),
    });
  },
};

// Check that each enumerable property and the constructor follow the
// expected behavior with regards to triggering style change events.
const properties = [
  ...Object.keys(Animation.prototype),
  'Animation constructor',
];

test(() => {
  for (const property of Object.keys(tests)) {
    assert_in_array(
      property,
      properties,
      `Test property '${property}' should be one of the properties on ` +
        ' Animation'
    );
  }
}, 'All property keys are recognized');

for (const key of properties) {
  promise_test(async t => {
    assert_own_property(tests, key, `Should have a test for '${key}' property`);
    const { setup, test, shouldFlush } = tests[key];

    // Setup target element
    const div = createDiv(t);
    let gotTransition = false;
    div.addEventListener('transitionrun', () => {
      gotTransition = true;
    });

    // Setup animation
    const animation = await setup(div);

    // Setup transition start point
    div.style.transition = 'opacity 100s';
    getComputedStyle(div).opacity;

    // Update specified style but don't flush
    div.style.opacity = '0.5';

    // Trigger the property
    test(animation);

    // If the test function produced a style change event it will have triggered
    // a transition.

    // Wait for the animation to start and then for at least two animation
    // frames to give the transitionrun event a chance to be dispatched.
    assert_true(
      typeof animation.ready !== 'undefined',
      'Should have a valid animation to wait on'
    );
    await animation.ready;
    await waitForAnimationFrames(2);

    if (shouldFlush) {
      assert_true(gotTransition, 'A transition should have been triggered');
    } else {
      assert_false(
        gotTransition,
        'A transition should NOT have been triggered'
      );
    }
  }, `Animation.${key} produces expected style change events`);
}
</script>
</body>
